iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Kubernetes

防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線系列 第 15

Day 15:讓 image 真的經過 Nexus —— path routing、Bearer Token

  • 分享至 

  • xImage
  •  

今日目的

Day 7 把 Nexus 裝起來了,但到目前為止沒有任何 image 真的經過它。今天把這條路走通:

  1. 節點端 podman 透過 docker-proxy 從 Docker Hub 拉到 image
  2. push 一張 image 到 docker-hosted,刪掉本地副本後再拉回
  3. ci namespace 的 Pod 安裝 npm 套件時,流量走 npm-group

擋在中間的是 Day 7 自己設的一個全域 outbound HTTP proxy(假設斷網環境的出入口)。那台 proxy 主機不存在,docker-proxy 一建起來就被 Nexus 打成 AUTO_BLOCKED_UNAVAILABLE,而錯誤訊息不會指向這裡。

先備知識

  • Day 7:Nexus 3 已部署完成,Invoke-NexusApi 的寫法,/service/extdirect 的呼叫形態
  • Day 12:cluster resolver 與 openshift-pipelines 的共用 Task
  • Docker Registry v2 的三段:/v2/ ping、manifest、blob

開場對照表

先前的說法 本次實測
Day 7 記錄的 nonProxyHosts["*.cluster.local","localhost"] 現況三筆,多了 registry.npmjs.org。建 npm-proxy 時被改過,Day 7 記的是當初值
Sonatype 有 HTTP Configuration API(/service/rest/v1/http 該端點 3.71.0 加入但標示 Pro 專屬。CE 上回 404,swagger 255 個端點裡無任何 */http*
proxy repo 被 AUTO_BLOCKED_UNAVAILABLE 之後要等封鎖窗口到期 不必等。改動全域 HTTP 設定會重建 HTTP client,狀態立刻轉回 AVAILABLE
Nexus docker repo 要另開埠(port connector) 3.83.0 起有 path-based routing,官方標為首選,port connector 標為 legacy。不動 Service、不開新 Route
CRC 的 ingress CA 就那一份 兩份,用途不能互換。1119 bytes(root only)給 git 與 CRI-O,2342 bytes(leaf+root)給 curl 與 podman
要在 Windows 上裝 podman 才能驗 CRC 節點本身有 podman 5.4.0,oc debug node 進去用

實測環境版本表

CRC 2.61.0+6eb443
OpenShift 4.21.14 / Kubernetes v1.34.6(單節點 crc)
Nexus Repository 3.93.0-06(COMMUNITY,nexus.loadAsOSS=true)
Chart stevehipwell/nexus3 5.23.0
節點 podman 5.4.0
用戶端:Windows 11 Pro + PowerShell 5.1(主機無 podman、無 docker)

下面所有結論只對這組版本成立。path-based routing 是 3.83.0 才加入的功能;coreui_HttpSettings 是未公開文件化的內部介面,換版本要重新核對欄位。


1. 共用前置

1.1 變數與認證

$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kc = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"
$ns = "nexus-proxy"

$workDir = Join-Path "$env:USERPROFILE\nexus-run" (Get-Date -Format "yyyyMMdd-HHmmss")
New-Item -ItemType Directory -Path $workDir -Force | Out-Null

$NEXUS_HOST = & $OC --kubeconfig $kc get route nexus -n $ns -o jsonpath='{.spec.host}'
$nexusUrl   = "https://$NEXUS_HOST"

$nexusPass = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
             (& $OC --kubeconfig $kc get secret nexus-admin -n $ns -o jsonpath='{.data.password}')))
$b64cred   = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("admin:$nexusPass"))

# ExtDirect 的 read payload。第 2 節與收工檢查清單都會用到,放這裡才能單獨重跑
$readBody = [ordered]@{ action="coreui_HttpSettings"; method="read"; data=$null; type="rpc"; tid=1 } |
            ConvertTo-Json -Depth 10 -Compress

1.2 Invoke-NexusApi

沿用 Day 7。認證走 -K 設定檔不進指令列,body 走 --data-binary "@檔案",暫存檔無 BOM 且在 finally 刪除。

if (-not ('TrustAllCertsPolicy' -as [type])) {
    Add-Type @"
using System.Net;
using System.Security.Cryptography.X509Certificates;
public class TrustAllCertsPolicy : ICertificatePolicy {
    public bool CheckValidationResult(
        ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; }
}
"@
}
[Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy

function Invoke-NexusApi {
    param([string]$Url, [string]$Method = "GET", [string]$JsonBody = $null)
    $cfg = Join-Path $env:TEMP ("nx-{0}.cfg" -f [guid]::NewGuid()); $tmp = $null
    try {
        [IO.File]::WriteAllText($cfg, "header = `"Authorization: Basic $b64cred`"",
                                (New-Object Text.UTF8Encoding $false))
        $a = @("-K",$cfg,"-k","-s","--fail-with-body","-X",$Method,
               "-H","Content-Type: application/json",$Url)
        if ($JsonBody) {
            $tmp = Join-Path $env:TEMP ("nx-{0}.json" -f [guid]::NewGuid())
            [IO.File]::WriteAllText($tmp,$JsonBody,(New-Object Text.UTF8Encoding $false))
            $a += @("--data-binary","@$tmp")
        }
        $raw = & curl.exe @a; $script:lastExit = $LASTEXITCODE; return ($raw -join "`n")
    } finally {
        if (Test-Path $cfg) { Remove-Item $cfg -Force }
        if ($tmp -and (Test-Path $tmp)) { Remove-Item $tmp -Force }
    }
}

Nexus 側的每一次呼叫都走 Route,不需要 oc exec

1.3 PowerShell 5.1 的四個地方

後面每一步都會碰到,先列出來。

一、ConvertTo-Json-Depth 預設是 2。 超過深度的結構被替換成型別名稱字串,不發警告,產出的 JSON 語法合法,ConvertFrom-Json 也不報錯。整條鏈上沒有任何環節會告訴你資料壞了。一律顯式指定 -Depth 10,並在送出前自檢:

"depth truncation = $($updBody -match 'OrderedDictionary|Hashtable')"   # 必須是 False

二、傳給原生 exe 的參數,未逸出的雙引號會消失。 引號其實有進到原始命令列,是接收行程的 argv 解析把它們當成分隔符吃掉的。所以 --% 不能解決這件事,\" 逸出可以,而最穩的是寫檔後用 --data-binary "@file"。症狀是 curl: no URL specified!,或接收端回報 invalid character 'i' in literal true

三、雙引號 here-string @"..."@ 會展開 $ 反斜線不是跳脫字元(跳脫是反引號),\$H 擋不住展開,變數被吃成空字串,送進 pod 的腳本變成 podman push /docker-hosted/smoke:v1。要注入值就用單引號 here-string 加佔位符:

$tpl = @'
printf '%s' '__PASS__' | podman login --username '__USER__' --password-stdin __HOST__
'@
$script = $tpl.Replace('__PASS__',$p).Replace('__USER__',$u).Replace('__HOST__',$NEXUS_HOST)

四、oc 的多行輸出是字串陣列。 [Text.Encoding]::UTF8.GetBytes($x) 會用空白接行——PEM 換行全沒了,而檔案大小看起來還是對的;$x.Length 回的是行數不是字元數;@($x).CountConvertFrom-Json 回空結果時會誤報成 1。拿 oc 輸出做長度或計數判斷之前,先確認型別。

要把腳本送進 pod 或節點執行,可行的形態是把整段 base64 化:

$sb64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($script))
& $OC --kubeconfig $kc debug node/crc -q -- chroot /host sh -c "echo $sb64 | base64 -d | sh"

base64 只有 A-Za-z0-9+/=,字串裡沒有雙引號,PowerShell 會因為有空白而替整串加上外層引號,argv 收到完整一段。不要用 oc cp 送檔——C:\... 的磁碟機冒號會被當成 pod:path 的分隔符,回 error: one of src or dest must be a local file specification


2. 清除全域 HTTP proxy

2.1 CE 上只有一條路

Sonatype 文件記載的唯一途徑是 UI 的 Settings → System → HTTP。要用指令做,四條候選裡只有一條可用:

途徑 狀態
/service/rest/v1/http 404。3.71.0 加入,release note 標示 Pro 專屬。這台 swagger 的 255 個端點裡沒有任何 */http*
Script API POST /v1/script 410 Gone。Groovy 引擎自 3.21.2 起預設停用,啟用需改 nexus.properties 並重啟
nexus.properties / JVM 參數 皆無 proxy 設定,組態只存在 config datastore
/service/extdirect 可用。UI 存檔時打的就是這支

回 404 而不是 403,代表端點未註冊而非權限不足。

coreui_HttpSettings 沒有相容性承諾,換版本要重驗欄位。

2.2 讀出並存檔

$beforeRaw = Invoke-NexusApi -Url "$nexusUrl/service/extdirect" -Method POST -JsonBody $readBody
[IO.File]::WriteAllText((Join-Path $workDir "before.json"), $beforeRaw,
                        (New-Object Text.UTF8Encoding $false))
($beforeRaw | ConvertFrom-Json).result.data | Format-List
userAgentSuffix  : null
timeout          : null
retries          : null
httpEnabled      : True
httpHost         : proxy.internal.local
httpPort         : 3128
httpsEnabled     : True
httpsHost        : proxy.internal.local
httpsPort        : 3128
nonProxyHosts    : {registry.npmjs.org, localhost, *.cluster.local}

這一份是唯一的回滾備份——CE 沒有其他方式匯出這組設定。

三件事一次看清楚:

nonProxyHostsregistry.npmjs.org 這解釋了同一台上 npm-proxy 一直正常、maven-centraldocker-proxy 全部 502 的原因:npm 走直連,另外兩個走一個解析不到的 proxy。

userAgentSuffix / timeout / retriesnull,所以 2.4 的 payload 不必帶它們。

Day 7 記的是兩筆,現況是三筆。 這組設定在建 npm-proxy 時被改過。

2.3 開關管轄規則

這是整段最容易靜默失敗的地方:

開關送 false 時,其管轄的欄位一併回存 null;開關未帶入時,管轄欄位不會寫入,而回應仍是 success: true

開關 管轄
httpEnabled httpHost / httpPort
httpsEnabled httpsHost / httpsPort
httpAuthEnabled httpAuthUsername / NtlmHost / NtlmDomain
httpsAuthEnabled 同上三個
(無開關) nonProxyHosts

四個開關全部要帶,不能只帶兩個,也不能靠「不帶就等於關」。

read 回傳 20 個欄位,沒帶進 payload 的一律變 null,所以 2.2 讀到的非 null 欄位必須原樣帶回來。

2.4 送出與獨立回讀

$clearData = [ordered]@{
    httpEnabled = $false; httpAuthEnabled = $false
    httpsEnabled = $false; httpsAuthEnabled = $false
}
$updBody = [ordered]@{
    action="coreui_HttpSettings"; method="update"; data=@($clearData); type="rpc"; tid=1
} | ConvertTo-Json -Depth 10 -Compress

"depth truncation = $($updBody -match 'OrderedDictionary|Hashtable')"
$updRaw = Invoke-NexusApi -Url "$nexusUrl/service/extdirect" -Method POST -JsonBody $updBody
$r = $updRaw | ConvertFrom-Json
"type = $($r.type) ; success = $($r.result.success)"
depth truncation = False
type = rpc ; success = True

data 必須用 @(...) 包成陣列,這是 ExtDirect 的形態。

判斷回應要看 type型別錯誤時 HTTP 仍是 200,頂層沒有 successtypeexception--fail-with-body 攔不到。

update 的回顯與寫入是同一次呼叫,不能拿來驗證自己。另發一次 read

$afterRaw = Invoke-NexusApi -Url "$nexusUrl/service/extdirect" -Method POST -JsonBody $readBody
[IO.File]::WriteAllText((Join-Path $workDir "after.json"), $afterRaw,
                        (New-Object Text.UTF8Encoding $false))
$stored = ($afterRaw | ConvertFrom-Json).result.data

$bad = @('httpEnabled','httpHost','httpPort','httpsEnabled','httpsHost','httpsPort') |
       Where-Object { $v = $stored.$_; -not (($null -eq $v) -or ($v -is [bool] -and -not $v)) }
if ($bad) { throw "未清除:$($bad -join ', ')" }
"全域 proxy 已關閉"

nonProxyHosts 沒帶入,回存為 null。proxy 關掉之後例外清單沒有意義,原值留在 before.json

2.5 用 maven-central 當探針

驗證這一步不必動到 docker。maven-central 是 Nexus 內建的 proxy repo,此刻已經存在,先前也一樣是 502(日誌同樣是 UnknownHostException: proxy.internal.local),但它沒有 realm、CA、帳號那一串前置:

Invoke-NexusApi -Url "$nexusUrl/repository/maven-central/org/slf4j/slf4j-api/2.0.13/slf4j-api-2.0.13.pom" | Out-Null
"maven-central exit=$lastExit"     # 0

它通了就代表 proxy 解決了,docker 之後再出問題就是 docker 自己的事。這個切分讓後面每一節都能單獨判斷成敗。

給先建 repo 才撞到這件事的人。 本文把清 proxy 排在建 repo 之前,照這個順序做根本不會看到封鎖狀態。若你是先建了 docker-proxy、拿到 502 才找到這裡,它此刻是 AUTO_BLOCKED_UNAVAILABLE,日誌那行帶 until <timestamp>,直覺會以為要等窗口過去——實測不用:

HttpClientFacetImpl - Repository status for maven-central changed from READY to AVAILABLE
HttpClientFacetImpl - Repository status for docker-proxy  changed from READY to AVAILABLE

改動全域 HTTP 設定本身就重建了 HTTP client 並清掉封鎖狀態。


3. 啟用 Docker Bearer Token Realm

docker 客戶端(含 podman)走的是 Bearer Token 流程,沒有這個 realm 連 login 都過不了。這台的 anonymous 是關的,所以一定要 login。

現在的 active realms 只有 NexusAuthenticatingRealm,要加進去不是取代:

Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/security/realms/active" `
    -Method PUT -JsonBody '["NexusAuthenticatingRealm","DockerToken"]' | Out-Null
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/security/realms/active"
["NexusAuthenticatingRealm","DockerToken"]

4. 建兩個 docker repository

4.1 為什麼用 path-based routing

Docker 客戶端不允許在 registry 位址裡帶 repository context path,所以要從三種 routing 擇一:port connector、subdomain、path。3.83.0 起 path-based routing 是官方首選,port connector 被標為 legacy。

URL 形態是:

nexus.example/repository-name/namespace/image:tag

不含 /repository/。這一段寫錯會直接 404。

選它的代價是省下來的:不必改 StatefulSet 的 containerPort、不必改 Service 的 ports、不必開新 Route,Helm values 完全不用碰

限制是官方明說同時只能用一種 routing,混用不受支援;之後要改回 port connector 得建新的 repository。

4.2 建立

只建 proxy 和 hosted,不建 group。CE 的 feature matrix 明列 Deployment to Group Repositories 為 Pro 專屬,group 只能拉不能推,這階段用不到。

$proxyBody = [ordered]@{
    name    = "docker-proxy"
    online  = $true
    storage = [ordered]@{ blobStoreName="default"; strictContentTypeValidation=$true }
    proxy   = [ordered]@{ remoteUrl="https://registry-1.docker.io"; contentMaxAge=1440; metadataMaxAge=1440 }
    negativeCache = [ordered]@{ enabled=$true; timeToLive=1440 }
    httpClient    = [ordered]@{ blocked=$false; autoBlock=$true }
    docker        = [ordered]@{ v1Enabled=$false; forceBasicAuth=$true; pathEnabled=$true }
    dockerProxy   = [ordered]@{ indexType="HUB" }
} | ConvertTo-Json -Depth 10 -Compress

Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/repositories/docker/proxy" -Method POST -JsonBody $proxyBody | Out-Null
"docker-proxy exit=$lastExit"      # 0

$hostedBody = [ordered]@{
    name    = "docker-hosted"
    online  = $true
    storage = [ordered]@{ blobStoreName="default"; strictContentTypeValidation=$true; writePolicy="ALLOW" }
    docker  = [ordered]@{ v1Enabled=$false; forceBasicAuth=$true; pathEnabled=$true }
} | ConvertTo-Json -Depth 10 -Compress

Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/repositories/docker/hosted" -Method POST -JsonBody $hostedBody | Out-Null
"docker-hosted exit=$lastExit"     # 0

這兩支成功時回 201 但 body 是空的Invoke-NexusApi 只回傳 body,所以直接印會什麼都看不到,成功和失敗長得一樣。要看 $lastExit

repository 總數從 9 變 11。回讀確認 pathEnabled 落地、httpPort / httpsPort / subdomain 皆空:

Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/repositories/docker/proxy/docker-proxy" |
    ConvertFrom-Json | Select-Object -ExpandProperty docker

4.3 協定層先驗,不必等客戶端

pathEnabled 有沒有生效,用 curl 打 Docker Registry v2 的路徑就看得出來,不必先裝好 CA 和帳號:

路徑 狀態
/v2/ 401
/v2/docker-proxy/library/alpine/manifests/3.20 401
/v2/library/alpine/manifests/3.20 404
/docker-proxy/v2/ 404

401 代表路徑被認得、只是要認證;404 代表路徑不存在。第二列是 path routing 的形態,它回 401 就是生效了。

foreach ($p in '/v2/',
               '/v2/docker-proxy/library/alpine/manifests/3.20',
               '/v2/library/alpine/manifests/3.20',
               '/docker-proxy/v2/') {
    "{0,-50} {1}" -f $p, (curl.exe -k -s -o NUL -w "%{http_code}" "$nexusUrl$p")
}

這裡刻意不帶認證,不要用 Invoke-NexusApi 它會帶上 Authorization header,帶了之後第二列就不是 401,而 401 與 404 的差別正是這張表唯一的訊號——認證過了反而看不出 path routing 有沒有生效。


5. 建一個有寫入權的帳號

push 需要寫入權,但不要直接用 nx-admin。建 repository 時 Nexus 會自動產生對應的 privileges,不必自己建:

nx-repository-view-docker-docker-{hosted,proxy}-{*,add,browse,delete,edit,read}
nx-repository-admin-docker-docker-{hosted,proxy}-{*,browse,delete,edit,read}
$roleBody = [ordered]@{
    id="ci-docker-push"; name="ci-docker-push"
    description="CI push to docker-hosted, pull through docker-proxy"
    privileges=@(
        "nx-repository-view-docker-docker-hosted-browse",
        "nx-repository-view-docker-docker-hosted-read",
        "nx-repository-view-docker-docker-hosted-add",
        "nx-repository-view-docker-docker-hosted-edit",
        "nx-repository-view-docker-docker-proxy-browse",
        "nx-repository-view-docker-docker-proxy-read"
    )
    roles=@()
} | ConvertTo-Json -Depth 10 -Compress
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/security/roles" -Method POST -JsonBody $roleBody

$dpw = [guid]::NewGuid().ToString()
$userBody = [ordered]@{
    userId="ci-docker"; firstName="CI"; lastName="Docker"
    emailAddress="ci-docker@example.local"
    password=$dpw; status="active"; roles=@("ci-docker-push")
} | ConvertTo-Json -Depth 10 -Compress
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/security/users" -Method POST -JsonBody $userBody

& $OC --kubeconfig $kc create secret generic ci-docker-credentials -n $ns `
    --from-literal=username=ci-docker --from-literal=password="$dpw"

這兩支和 4.2 不一樣,成功時回 200 且帶完整 JSON body,直接印就看得到結果,不必再查 $lastExit

docker-proxy 的讀取權不能漏。 拉取要讀 proxy、推送要寫 hosted,是兩組權限;只給 hosted 的話第 7 節會 401。proxy 只給讀,不給寫。

密碼建完就無處可查,比照 ci-npm-credentials 的慣例存成 Secret。


6. 讓節點的 podman 信任 Route 憑證

6.1 節點本身就有 podman

主機沒有 podman 也沒有 docker,crc podman-env 明說 podman-remote 已不再隨附。但節點有:

& $OC --kubeconfig $kc debug node/crc -- chroot /host podman --version
podman version 5.4.0

用節點端還有一個好處:它比 Windows 更接近之後 buildah 在叢集內跑的環境。

注意節點的 podman 與 CRI-O 共用同一個 storage(/var/lib/containers/storage),podman images 看得到 CRI-O 拉過的 operator index。做實驗時要留意磁碟。

6.2 CA 的四個位置互不相干

Docker 要求 HTTPS,Nexus 不支援 --insecure-registry,所以憑證信任是硬需求。CRC 的 ingress CA 有兩份不同內容的檔案,用途不能互換:

使用者 位置 檔案
主機 curl / podman %USERPROFILE%\.config\containers\certs.d\<host>\ca.crt 2342(leaf+root)
節點 podman /etc/containers/certs.d/<host>/ca.crt 2342
節點 CRI-O openshift-config/registry-certsimage.config.openshift.io/cluster 1119(root only)
git %USERPROFILE%\certs\crc-ingress-ca.crt 1119

挑錯檔的症狀是 curl 回 000、podman 回 x509: certificate signed by unknown authority

certs.d 的目錄名必須與 image reference 裡的用法完全一致——image ref 不帶 port,目錄名就不能寫成 host:443

6.3 安裝

從 configmap 取,不要用家目錄裡來路不明的檔案:

$lines = & $OC --kubeconfig $kc get configmap default-ingress-cert -n openshift-config-managed `
         -o jsonpath='{.data.ca-bundle\.crt}'
$ca = ($lines -join "`n")          # 沒有這個 join,PEM 換行會被空白取代
"chars = $($ca.Length)"            # 2342

$cab64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ca))
$tpl = @'
set -e
D=/etc/containers/certs.d/__HOST__
mkdir -p $D
echo '__CA__' | base64 -d > $D/ca.crt
openssl x509 -in $D/ca.crt -noout -subject
curl -s -o /dev/null -w 'HTTP=%{http_code}\n' --max-time 10 --cacert $D/ca.crt \
  https://__HOST__/service/rest/v1/status
'@
$script = $tpl.Replace('__HOST__',$NEXUS_HOST).Replace('__CA__',$cab64)
$sb64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($script))
& $OC --kubeconfig $kc debug node/crc -q -- chroot /host sh -c "echo $sb64 | base64 -d | sh"
subject=CN=*.apps-crc.testing
HTTP=200

7. 拉、推、拉回

7.1 login 不帶 path,pull 帶 path

podman login nexus-nexus-proxy.apps-crc.testing                                    ← 不帶 path
podman pull  nexus-nexus-proxy.apps-crc.testing/docker-proxy/library/alpine:3.20   ← 帶 path

login 的請求打的是 registry 根目錄的 /v2/,帶 repository path 會認證失敗。這兩行位址不一樣,是這一段最容易搞混的地方。

$u = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
     (& $OC --kubeconfig $kc get secret ci-docker-credentials -n $ns -o jsonpath='{.data.username}')))
$p = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
     (& $OC --kubeconfig $kc get secret ci-docker-credentials -n $ns -o jsonpath='{.data.password}')))

$tpl = @'
set -e
H=__HOST__
printf '%s' '__PASS__' | podman login --username '__USER__' --password-stdin $H

podman pull $H/docker-proxy/library/alpine:3.20
podman run --rm $H/docker-proxy/library/alpine:3.20 cat /etc/alpine-release

podman pull registry.access.redhat.com/ubi9-micro:latest
podman tag  registry.access.redhat.com/ubi9-micro:latest $H/docker-hosted/smoke:v1
podman push --remove-signatures $H/docker-hosted/smoke:v1
podman rmi  $H/docker-hosted/smoke:v1
podman pull $H/docker-hosted/smoke:v1
'@
$script = $tpl.Replace('__HOST__',$NEXUS_HOST).Replace('__PASS__',$p).Replace('__USER__',$u)
$sb64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($script))
& $OC --kubeconfig $kc debug node/crc -q -- chroot /host sh -c "echo $sb64 | base64 -d | sh"

Login Succeeded! 這一行同時證明四件事一起生效:DockerToken realm、path routing、Route 的 edge TLS、ci-docker 的權限。

拉下來的 alpine 跑一下確認不是空殼:

3.20.10

7.2 --remove-signatures

Red Hat 官方 image 帶簽章,轉存到 Nexus 需要改變層的表示方式,不加這個旗標會失敗:

Error: Copying this image would require changing layer representation,
       which we cannot do: "Would invalidate signatures"

之後 buildah 推自建 image 不會遇到(沒有簽章),但拿官方 image 做搬運測試一定會撞到。

加了之後 blob digest 會變,config digest 不變d09203066f26)——用 config digest 判斷內容一致性。

先用 alpine:3.20(8.1 MB)與 ubi9-micro(23.7 MB),失敗時重試成本低。

7.3 通過條件

Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/components?repository=docker-proxy" |
    ConvertFrom-Json | Select-Object -ExpandProperty items | Select-Object name, version
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/components?repository=docker-hosted" |
    ConvertFrom-Json | Select-Object -ExpandProperty items | Select-Object name, version
library/alpine   3.20
smoke            v1

8. npm 側

Pod 只能掛載自己 namespace 的 Secret,這是 Kubernetes 的硬限制,跨 namespace 給再多 RBAC 也沒用。憑證要複製過去:

$u = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
     (& $OC --kubeconfig $kc get secret ci-npm-credentials -n $ns -o jsonpath='{.data.username}')))
$p = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
     (& $OC --kubeconfig $kc get secret ci-npm-credentials -n $ns -o jsonpath='{.data.password}')))
& $OC --kubeconfig $kc create secret generic ci-npm-credentials -n ci `
    --from-literal=username="$u" --from-literal=password="$p"

.npmrc 在 Pod 執行期產生,密鑰從環境變數來,不進 YAML:

AUTH=$(printf '%s:%s' "$NPM_USER" "$NPM_PASS" | base64 -w0)
cat > .npmrc <<EOF
registry=http://nexus-nexus3.nexus-proxy.svc.cluster.local:8081/repository/npm-group/
always-auth=true
//nexus-nexus3.nexus-proxy.svc.cluster.local:8081/repository/npm-group/:_auth=${AUTH}
EOF

三件事:

npm 走的是標準 /repository/ 路徑,和 docker 的 path routing 不同。path routing 只影響 docker format,兩種形態在同一台 Nexus 上並存是正常的。

不需要開 NpmToken realm。 Sonatype 文件明列,無法使用 realm + login 流程時可改用 _auth 的 basic auth,在只有 NexusAuthenticatingRealm 的情況下就夠用。npm view express 回得出版本就是通了。

叢集內走 Service 的 8081(HTTP),不走 Route。 npm 不要求 HTTPS,docker 才要求。走 Service 可以避開 Pod 對 *.apps-crc.testing 的解析問題。代價是同一個 Nexus 在節點端與叢集內是兩個不同位址。

測試 Pod 的 image 要用 registry.access.redhat.com/ubi9/nodejs-22(免認證),不要用 image-registry.openshift-image-registry.svc:5000/openshift/nodejs:22-ubi9——openshift 的 ImageStream 指向外部 registry,映像本身不在內部 registry 裡,Pod 會卡在 ContainerCreating 而事件只有 Pulling、沒有錯誤訊息。

這裡跑的是 npm install 而不是 npm ci 這個 repo 的 package.jsonpackage-lock.json 不同步(lock 裡是 yaml@1.10.3,依賴樹要 2.9.0),npm ci 會在兩秒內拒絕執行,連 registry 都不碰。那是原始碼的問題、與 Nexus 無關,但錯誤訊息(can only install packages when your package.json and package-lock.json are in sync)看起來像 npm 設定壞掉,很容易往 registry 方向查。

一次完整安裝(Nx 23.1.1 + Angular 22.0.4,package-lock.json 一百萬 bytes):

added 1720 packages in 3m(200s)        node_modules 676 MB
blobstore  12.6 MB → 665.9 MB           +2,496 blobs / +653 MB

一次 Angular 安裝就吃掉 653 MB。這台所有 PV 共用同一顆 180G 的盤,規劃時要算進去。


收工前檢查清單

# 1. 全域 proxy 已關(重讀,不要用 2.4 的 $afterRaw——清單的用途是隔一段時間回來確認)
(Invoke-NexusApi -Url "$nexusUrl/service/extdirect" -Method POST -JsonBody $readBody |
 ConvertFrom-Json).result.data.httpHost                      # null

# 2. realm
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/security/realms/active"
#   ["NexusAuthenticatingRealm","DockerToken"]

# 3. 兩個 docker repo 都在,pathEnabled 落地
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/repositories/docker/proxy/docker-proxy" |
    ConvertFrom-Json | Select-Object -ExpandProperty docker
#   pathEnabled = True

# 4. 上游真的通了(與 docker 無關的獨立探針)
Invoke-NexusApi -Url "$nexusUrl/repository/maven-central/org/slf4j/slf4j-api/2.0.13/slf4j-api-2.0.13.pom" | Out-Null
"maven-central exit=$lastExit"           # 0

# 5. 兩個 repo 都有內容
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/components?repository=docker-proxy"
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/components?repository=docker-hosted"

# 6. 節點的 CA 是 2342
& $OC --kubeconfig $kc debug node/crc -q -- chroot /host `
    wc -c "/etc/containers/certs.d/$NEXUS_HOST/ca.crt"

# 7. before.json 還在(唯一的回滾依據)
Test-Path (Join-Path $workDir "before.json")

卡住時的判斷順序

症狀 先看 不要先做
502 {"code":"UNKNOWN"} 第 2 節的全域 proxy。打 maven-central 對照 不要懷疑 repo 設定或 path routing
login 回 x509 第 6 節的 CA,是不是拿成 1119 bytes 不要加 --tls-verify=false
login 回 401 第 3 節的 realm 有沒有生效 不要先懷疑帳號密碼
login 過了但 pull 404 URL 有沒有多寫 /repository/ 不要改 repository 設定
pull 回 401 login 位址有沒有誤帶 path;role 有沒有給 docker-proxy 讀取權 同上
push 回 "Would invalidate signatures" --remove-signatures 不要改 repository 設定
npm 401 .npmrc_auth key 是不是完整路徑 不要先開 NpmToken realm
npm ci 兩秒就失敗 package-lock.jsonpackage.json 是否同步 不要懷疑 registry
Pod 卡 ContainerCreating、事件只有 Pulling image 是不是指向內部 registry 但映像不在那裡 不要先查網路

回滾

第 2 節修改了既有設定,其餘都是加法。

第 2 節  把 before.json 的 result.data 20 個欄位原樣組成 update payload 送回
         四個 *Enabled 必須全部帶入
第 3 節  PUT /v1/security/realms/active  ["NexusAuthenticatingRealm"]
第 4 節  DELETE /v1/repositories/docker-proxy、docker-hosted
第 5 節  DELETE /v1/security/users/ci-docker、roles/ci-docker-push
         oc delete secret ci-docker-credentials -n nexus-proxy
第 6 節  刪節點的 /etc/containers/certs.d/<host>/
第 7 節  節點 podman logout、podman rmi
第 8 節  oc delete secret ci-npm-credentials -n ci

沒有動到 Helm release、StatefulSet、Service、Route、nexus.propertiesimage.config.openshift.io/cluster、節點 registries.conf、Day 14 的觸發鏈路。這是選 path routing 換來的。


收尾

image 側到這裡通了:拉得進來、推得上去、拉得回去,全程沒有重啟、沒有停機、沒有動 UI。

三個值得帶走的。

設定會漂移,文件記的是當初值。 Day 7 記的 nonProxyHosts 是兩筆,現況三筆。拿舊文件解釋新現象之前,先確認那份文件描述的還是不是現在的狀態。這和「官方文件說有這個端點」對上「這台回 404」是同一件事的兩面:文件的版別、版本、時點,三個都要對得上才能引用。

查詢回空不等於沒有東西。 先前盤點時打 /service/rest/v1/system/http-proxy 拿到空 body,記成「沒有設任何 HTTP proxy」。那個端點根本不存在,空 body 是 404 的空 body。一個既存設定因此多躲了一輪,直到 502 才浮出來。

Nexus 的 JVM heap 已經到 80%。

MemoryMonitor - High heap usage: 80% (827.6mb/1.0gb), sustained=30000ms

chart 預設是 -Xms1024m -Xmx1024m,而目前只跑過一次 npm 安裝和兩張小 image。下一步把 buildah 接進 Pipeline 推應用 image 時,這是第一個會出事的地方,而且它的失敗形態會偽裝成網路問題——push 逾時、502、連線中斷,讓人先去查 Route 和 TLS。接 buildah 之前先調它。

還沒解決的三項一併列出:叢集內走 Service 8081 的 path routing 未驗(接 buildah 時會變成阻塞)、Route 對大型 blob 上傳未驗(目前推過最大 23.7 MB)、CRC VM 時鐘偏移 8 小時(oc describe 的 event 年齡全部偏移,排查時會誤導)。


參考資料

Sonatype

文件 用到的結論
Docker Registry path-based routing 為 3.83.0 新增、標示 preferred,port connector 標示 legacy;URL 不含 /repository/;同時只能用一種 routing;不支援 --insecure-registry
Docker Authentication Docker Bearer Token Realm 為必要條件;login 位址不可帶 repository path
Self-Hosted Feature Matrix Docker、npm 在 CE 可用;Deployment to Group Repositories 為 Pro 專屬
npm Security //<host>/<path>/:_auth=<base64> 的 basic auth
HTTP Configuration API /service/rest/v1/http 的規格。列在這裡是記錄它存在但 CE 不適用
HTTP Request and Proxy Settings 全域 outbound proxy 的欄位語意;官方途徑為 UI
Script API Groovy 引擎自 3.21.2 起預設停用
Realms active realms 的清單語意
Repositories API docker proxy / hosted 的 POST body
Security Management API realms / roles / users 端點
3.71.0 Release Notes HTTP Configuration API 的加入版本,標示 Pro
3.83.0 Release Notes path-based routing 的加入版本

容器與 OpenShift

文件 用到的結論
containers-certs.d(5) <certs.d>/<host[:port]>/ca.crt 的目錄結構;port 部分須與 image reference 一致
Configuring Routes(OCP 4.10) haproxy.router.openshift.io/timeout 的用法與時間單位。這是 4.10 的文件,本文環境是 4.21,真要調之前該對一次對應版本

本系列

用到的結論
Day 7:部署 Nexus 3 並配置 Outbound Proxy Invoke-NexusApi 的寫法;coreui_HttpSettings 的 read / update payload;開關管轄規則;nonProxyHosts 須為陣列;型別錯誤時 HTTP 仍是 200;寫入後必須另發獨立 read。本文要清掉的那個 proxy 就是這篇設的
Day 12:跨 Namespace 引用 Task(Cluster Resolver) openshift-pipelines 的共用 Task,接 buildah 的前置
Day 14:Tekton Triggers 的授權規範與 CEL 的邊界 不為跨 namespace 讀取而綁 -clusterrolesci 既有的觸發鏈路

上一篇
Day 14:自動觸發機制 —— Tekton Triggers 的授權規範與 CEL 的邊界
下一篇
Day 16:單節點的 GC 門檻與 Nexus cleanup policy
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言